Skip to content

feat(mcp): per-entity PO modify card with field-level diff overlay — #722#755

Merged
dougborg merged 1 commit into
mainfrom
feat/po-modify-card-722
May 16, 2026
Merged

feat(mcp): per-entity PO modify card with field-level diff overlay — #722#755
dougborg merged 1 commit into
mainfrom
feat/po-modify-card-722

Conversation

@dougborg
Copy link
Copy Markdown
Owner

Summary

First migration in the #721 modify-card umbrella. Replaces the today's PO modify card (which showed internal-model abstractions like "Update Header / 2 field(s) changed / Operation #1, Target #2641831") with a per-entity entry that mirrors the create-card layout and decorates the changing fields with `before → after`.

What lands

Hybrid status design (agreed in #721 design session)

The card-level header Badge already tells you APPLIED / PARTIAL FAILURE / FAILED at a glance. Per-field decoration only carries information in the failure case — so:

  • Preview: every changed field renders `Label: before → after`. No glyph, no badge.
  • All-applied: same `Label: before → after` (user wants to see what changed). Header badge says APPLIED. No per-field glyph.
  • Partial failure: failed fields get leading `✗` + trailing Muted error line. Successful fields stay glyph-free. Header badge says PARTIAL FAILURE.

This trims real estate dramatically — the all-success case is just diffs, no status furniture. The user only "pays" the glyph + error-line cost when there's actually something to look at.

What this PR does NOT touch (deferred to follow-ups)

Tests

  • `TestFieldDiffIndex` — pins `_index_changes_by_field` projection (multi-action flattening, added/unchanged kinds, failed-action error propagation).
  • `TestBuildPOModifyUI` — smoke + diff-decoration + supplier composite diff + failed-action `✗` glyph + delete verb + partial-failure header badge + `state.result` seeding on applied path.
  • `TestPOEntityViewSharedBetweenCreateAndModify` — pins the dual-call contract so a future refactor of `_render_po_entity_view` can't silently drift the create card away from modify.

Test plan

  • `uv run poe check` clean (3231 unit + 19 browser tests pass)
  • `uv run pyright` clean on touched files
  • CI green
  • Live sanity: hit `modify_purchase_order` in Claude Desktop with `preview=true`, verify the new card renders correctly + Confirm morphs to applied state

Closes #722
Refs #721

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings May 15, 2026 16:27
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements the first step of the #721 modify-card redesign by migrating purchase-order modification UIs to a per-entity Prefab card that overlays field-level before → after diffs, and updates modification dispatch to route PO responses to the new builder while leaving other entities on the legacy path.

Changes:

  • Added shared field-diff projection utilities (FieldChangeView, _index_changes_by_field, _render_field_diff_line) for modify-card rendering.
  • Extracted a shared PO entity-view renderer (_render_po_entity_view) and introduced build_po_modify_ui for PO modify/delete/correct cards.
  • Updated _modification.to_tool_result to dispatch entity_type == "purchase_order" to the new PO modify UI; added targeted tests for the new helpers and PO modify card.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py Adds shared field-diff helpers, extracts shared PO entity view, and introduces build_po_modify_ui using direct-apply scaffolding.
katana_mcp_server/src/katana_mcp/tools/_modification.py Updates modification ToolResult UI dispatch to route purchase orders to build_po_modify_ui, with other entities falling back to legacy builders.
katana_mcp_server/tests/test_prefab_ui.py Adds unit tests for _index_changes_by_field and smoke/behavior tests for the new PO modify UI + shared entity-view contract.
Comments suppressed due to low confidence (4)

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2303

  • build_po_modify_ui reuses _render_preview_header, but that helper hard-codes create-card wording (title suffix "Created" and state badge "CREATED"). In applied modify/delete/correct flows this will render incorrect UI copy (e.g., "Modify Purchase Order Created"). Consider adding a modify-aware header helper or making _render_preview_header accept the applied-state label/title suffix (e.g., APPLIED/DELETED/CORRECTED).
        _render_preview_header(
            title_prefix=f"{verb_label} Purchase Order",
            entity="purchase_order",
            order_number=str(entity.get("order_number") or entity_id or "N/A"),
            status=entity.get("status"),
            extra_badges=extra_badges,
        )

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2330

  • build_po_modify_ui calls _render_preview_footer, whose applied-state message is always "{title_prefix} created.". With title_prefix=f"Purchase Order {verb_label}" this produces incorrect text like "Purchase Order Delete created." on applied cards. This likely needs a modify-specific footer message (applied/delete/correct) or a parameter to _render_preview_footer to customize the applied-state copy for non-create operations.
        _render_preview_footer(
            title_prefix=f"Purchase Order {verb_label}",
            block_warnings=block_warnings,
            confirm_label=confirm_label,
            apply_action=apply_action,
            cancel_action=cancel_action,
            # No next-action buttons on modify cards by default — the
            # user already had the PO they wanted to change; surfacing
            # "Receive Items" here would be noise. Delete operations
            # also have nothing useful to suggest (the PO is gone).
            next_action_buttons=(),
        )

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2271

  • entity = {**prior_state, **{k: v for k, v in response.items() if v is not None}} assumes the response dict contains the post-change PO fields. But to_tool_result() passes ModificationResponse.model_dump(), and ModificationResponse does not include PO fields like status, supplier_name, order_number, etc. As a result, applied cards will continue to show the prior status/order/supplier in the header/body unless you explicitly compute a post-state by applying changes_by_field[*].after onto prior_state (or otherwise supply the post-state).
    verb_label = _verb_label(confirm_tool)
    # Compose the entity view's source-of-truth dict by overlaying the
    # response on top of prior_state. Preview: prior_state is the full
    # pre-change snapshot; response-level scalars (warnings, katana_url)
    # win. Applied: the response carries the post-change scalars too —
    # the same overlay yields the post-change entity, modulo nested
    # collections which the dispatcher already updated.
    entity = {**prior_state, **{k: v for k, v in response.items() if v is not None}}
    # ``entity_id`` from the response is the PO's identity; surface it
    # under the same key the create-card pattern uses so the header
    # Badge picks it up regardless of which payload populated it.
    if entity_id is not None:
        entity.setdefault("id", entity_id)

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2251

  • build_po_modify_ui depends on response["prior_state"] for rendering the PO entity view (supplier/location/metrics). However run_modify_plan() currently does not populate prior_state on the preview path (it only sets it on apply), so real modify_purchase_order previews will render without those entity fields. Either include a serialized prior_state in preview ModificationResponse when existing is available, or adjust this builder to explicitly handle the missing-prior_state case (e.g., render only diff lines + a warning).
    actions = response.get("actions") or []
    is_preview = bool(response.get("is_preview", True))
    entity_id = response.get("entity_id")
    prior_state = response.get("prior_state") or {}
    # Note: katana_url is read from RESULT via the Prefab template

Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.

Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2316

  • _summarize_apply_outcome returns (state_label, badge_variant) but current call sites only use [0], and the computed badge_variant is not applied anywhere. Either drop the unused return value (return just the label) or wire the variant into _render_preview_header (e.g., via an applied_state_variant parameter) so FAILED/PARTIAL FAILURE can render as destructive as intended.
    """Bucket a modify response's action outcomes for the header Badge.

    Returns ``(state_label, badge_variant)``. Variants align with the
    create-card Tier 1 vocabulary (``default`` / ``secondary`` /
    ``destructive`` / ``outline``).

Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.

Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2481

  • In the direct-apply (preview → Confirm) flow, the iframe keeps the original Prefab tree and only updates state via SetState("result", RESULT) + SetState("applied", True). However, this card’s body is rendered from the initial response/entity/changes_by_field, so after apply it will still show the preview response["message"] and preview-time diffs, and it cannot surface apply-time failures (✗ + error) because changes_by_field was computed from preview actions (succeeded=None). To make applied/partial-failure UI accurate in the in-place morph path, render applied-state content from state.result (or populate dedicated state fields in extra_on_success) and switch the body/message/diff source when applied is true.
        with CardContent(), Column(gap=3):
            if response.get("message"):
                Muted(content=response["message"])
            block_warnings = _render_po_entity_view(entity, changes=changes_by_field)
        # Confirm label scales with the number of planned actions —

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2466

  • On the standalone-applied path, _summarize_apply_outcome(actions) can set the state badge to FAILED/PARTIAL FAILURE, but the title suffix and footer verb remain hardcoded to "Applied"/"applied" for modify/correct. That makes the card read e.g. "Modify Purchase Order Applied" and "… applied." even when the outcome is FAILED, which is misleading. Consider deriving the applied title/footer copy from the same outcome summary (at least for full failure) so the user-visible text stays consistent with the header badge.
        else:
            applied_title_suffix = "Applied"
            applied_state_label = "APPLIED"
            applied_verb = "applied"
        applied_state_variant = "default"

        # On the standalone-applied path (is_preview=False), let the
        # actual outcome drive both the state label AND the badge
        # variant — so a fully-failed apply reads "FAILED" with the
        # destructive (red) variant, not "APPLIED" with the success
        # (green) variant. Partial failures also surface in the
        # destructive variant. The in-place morph path can't know the
        # outcome at build time, so it keeps APPLIED/DELETED + default
        # — failed actions there surface via the per-field ✗ glyphs.
        if not is_preview:
            outcome_label, outcome_variant = _summarize_apply_outcome(actions)
            if outcome_label != "APPLIED":
                applied_state_label = outcome_label
                applied_state_variant = outcome_variant

Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2165

  • _render_po_entity_view reads PO fields using the create-card response shape (total_cost, item_count, etc.). But ModificationResponse.prior_state is serialized from the Katana API model (RegularPurchaseOrder.to_dict()), which uses different keys (e.g. total, purchase_order_rows, additional_info, order_no, nested supplier). As a result, real modify/delete/correct cards will likely omit the Total/Line Items metrics (and other context) even when prior_state is present. Consider normalizing the PO snapshot into the create-card field names (or add fallbacks like total_cost or total and derive item_count from purchase_order_rows) before rendering.
    changes = changes or {}
    total_cost = entity.get("total_cost")
    currency = entity.get("currency")
    item_count = entity.get("item_count")

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2472

  • build_po_modify_ui renders the header order-number and status from entity.get("order_number") / entity.get("status"), but the entity dict is primarily composed from prior_state and does not apply actions[*].changes to compute the post-change values. This contradicts the inline comment in _render_po_entity_view that status changes “already surface in the Tier 1 header Badge”, and can cause the header badge(s) to show the pre-change status/number while the body diff shows the new value. Consider deriving header fields from changes_by_field when present (e.g. status_change.after, order_no/order_number change.after) and falling back to the snapshot only when no diff is available.
        _render_preview_header(
            title_prefix=f"{verb_label} Purchase Order",
            entity="purchase_order",
            order_number=str(entity.get("order_number") or entity_id or "N/A"),
            status=entity.get("status"),

Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Comment thread katana_mcp_server/tests/test_prefab_ui.py Outdated
Comment thread katana_mcp_server/src/katana_mcp/tools/_modification.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 1 comment.

Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (3)

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:1417

  • The module comment says failed fields render a leading ✗ plus an inline error line, but the implementation aggregates error messages into the bottom Alert via _render_failed_changes_block (and explicitly avoids inline error text). Please update this comment to match the actual rendering behavior to avoid misleading future maintainers.

This issue also appears in the following locations of the same file:

  • line 1541
  • line 2516
]


def _format_qty_change(qty: float) -> str:
    """Format a quantity change with leading sign (e.g., ``+1.0``, ``-3.5``)."""
    return f"{qty:+.1f}"

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:1546

  • _render_field_diff_line docstring describes rendering an inline Muted error line when change.failed is True, but the function intentionally does not render inline error text (errors are consolidated in _render_failed_changes_block). Update the docstring to reflect the actual behavior.
    parent action's ``succeeded`` / ``error`` so failed actions surface
    per-field on every field they were going to write. A field appearing
    in two actions (rare) takes the last write — the iteration order
    matches the action plan's execution order, so the last write is the
    one that ran (or would have run) most recently.
    """

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2521

  • build_po_modify_ui docstring says failed fields show a leading ✗ glyph + inline error, but errors are actually consolidated into the bottom Alert block. Updating this docstring would keep the documented contract consistent with the renderer.
        with CardContent(), Column(gap=3):
            block_warnings = _render_po_entity_view(response)
        _render_preview_footer(
            title_prefix="Purchase Order",
            block_warnings=block_warnings,
            confirm_label="Confirm & Create Purchase Order",

Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Comment thread katana_mcp_server/tests/test_prefab_ui.py Outdated
@dougborg dougborg force-pushed the feat/po-modify-card-722 branch from a6368b9 to d4b7502 Compare May 15, 2026 23:47
Copilot AI review requested due to automatic review settings May 15, 2026 23:53
@dougborg dougborg force-pushed the feat/po-modify-card-722 branch from d4b7502 to 8d4f7b7 Compare May 15, 2026 23:53
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.

Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.

Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py
Comment thread katana_mcp_server/src/katana_mcp/tools/prefab_ui.py Outdated
@dougborg dougborg force-pushed the feat/po-modify-card-722 branch from 80155b1 to ebf33b8 Compare May 16, 2026 01:13
…722

Foundation + first migration for the #721 modify-card umbrella. Replaces
the today's ``ActionResult``-shaped modify card (which showed "Update
Header / 2 field(s) changed / Operation #1, Target #2641831" — internal
model abstractions the user can't action on) with a per-entity entry
that mirrors the create-card layout and decorates the changing fields
with ``before → after``.

What lands:

- ``FieldChangeView`` + ``_index_changes_by_field`` + ``_render_field_diff_line``
  — renderer-facing field-diff projection, shared by every future modify
  card. Flattens ``ActionResult.changes`` across all actions into a single
  field-name keyed map so the entity view's render code can do simple
  ``changes.get("expected_arrival_date")`` lookups.

- ``_render_po_entity_view(entity, *, changes=None)`` — the PO entity-view
  body, extracted from the old inlined ``build_po_create_ui`` Tier 2/3
  content. Called by BOTH the create card (``changes=None``) and the new
  modify card. Single source of truth for "what fields show in a PO card";
  the create-modify pair can't drift over time.

- ``build_po_modify_ui`` — the new entrypoint. Handles
  ``modify_purchase_order`` / ``delete_purchase_order`` /
  ``correct_purchase_order`` (verb from ``confirm_tool``). Renders the
  entity view with the diff overlay; the leading ``✗`` glyph + inline
  Muted error line appears only on failed actions (the agreed hybrid
  status approach — card-level header Badge carries the all/most-case
  status, per-field decoration only when it carries information).

- Dispatch in ``_modification.to_tool_result`` — switches on
  ``response.entity_type``. PO routes to the new entrypoint; other
  entities fall through to the legacy ``build_modification_preview_ui`` /
  ``build_modification_result_ui`` pair until their child PRs (#723 SO,
  #724 MO, #725 stock-transfer, #726 item) ship. The legacy builders
  delete once every entity migrates.

Tests:

- ``TestFieldDiffIndex`` — pins ``_index_changes_by_field`` projection
  (multi-action flattening, added/unchanged kinds, failed-action error
  propagation).
- ``TestBuildPOModifyUI`` — smoke + diff-decoration + supplier composite
  diff + failed-action ``✗`` glyph + delete verb + partial-failure
  header badge + ``state.result`` seeding on applied path.
- ``TestPOEntityViewSharedBetweenCreateAndModify`` — pins the dual-call
  contract so a future refactor of ``_render_po_entity_view`` can't
  silently drift the create card away from modify.

What this PR does NOT touch (deferred to follow-ups):

- Line-item / additional-cost adds/removes (the ``add_row`` / ``delete_row``
  / ``add_additional_cost`` operations) — the entity view doesn't yet
  render those rows. The current PO create card doesn't render them
  either, so this is consistent with #728's create-card scope, not a
  regression. Adding the nested-rows section is its own follow-up
  (probably the next PO modify PR or rolled into the SO migration where
  the entity-view component for line-items first matters).
- Other entities (SO, MO, stock_transfer, item) — child PRs #723-#726.

Closes #722
Refs #721

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 16, 2026 02:09
@dougborg dougborg force-pushed the feat/po-modify-card-722 branch from ebf33b8 to 8516ffb Compare May 16, 2026 02:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.

@dougborg dougborg merged commit 5713459 into main May 16, 2026
18 checks passed
@dougborg dougborg deleted the feat/po-modify-card-722 branch May 16, 2026 02:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

design(mcp): build_po_modify_ui — diff-decorated PO modify card (#721 child)

2 participants